summaryrefslogtreecommitdiff
path: root/app/[lng]
diff options
context:
space:
mode:
Diffstat (limited to 'app/[lng]')
-rw-r--r--app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx69
-rw-r--r--app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx3
-rw-r--r--app/[lng]/partners/(partners)/tbe-last/page.tsx88
-rw-r--r--app/[lng]/pdftron-viewer/page.tsx507
4 files changed, 664 insertions, 3 deletions
diff --git a/app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx b/app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx
new file mode 100644
index 00000000..097b99eb
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/rfq-last/[id]/compare/page.tsx
@@ -0,0 +1,69 @@
+import { Suspense } from "react";
+import { notFound } from "next/navigation";
+import { QuotationCompareView } from "@/lib/rfq-last/quotation-compare-view";
+import { Loader2 } from "lucide-react";
+import { getComparisonData } from "@/lib/rfq-last/compare-action";
+
+interface ComparePageProps {
+ params: {
+ id: string;
+ };
+ searchParams: {
+ vendors?: string;
+ };
+}
+
+export default async function ComparePage({
+ params,
+ searchParams
+}: ComparePageProps) {
+ const rfqId = parseInt(params.id);
+
+ console.log(rfqId,"rfqId")
+ console.log(searchParams.vendors,"searchParams.vendors")
+
+ // URL에서 벤더 ID들 파싱
+ const vendorIds = searchParams.vendors
+ ?.split(',')
+ .map(id => parseInt(id))
+ .filter(id => !isNaN(id)) || [];
+
+ if (!rfqId || vendorIds.length < 2) {
+ notFound();
+ }
+
+ // 서버에서 데이터 가져오기
+ const data = await getComparisonData(rfqId, vendorIds);
+
+ if (!data) {
+ notFound();
+ }
+
+ return (
+ <div className="container mx-auto p-6 space-y-6">
+ {/* 페이지 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">견적 비교</h1>
+ <p className="text-muted-foreground">
+ {data.rfqInfo.rfqCode} - {data.rfqInfo.rfqTitle}
+ </p>
+ </div>
+ <div className="text-sm text-muted-foreground">
+ 비교 업체: {data.vendors.length}개
+ </div>
+ </div>
+
+ {/* 비교 뷰 컴포넌트 */}
+ <Suspense
+ fallback={
+ <div className="flex items-center justify-center h-64">
+ <Loader2 className="h-8 w-8 animate-spin" />
+ </div>
+ }
+ >
+ <QuotationCompareView data={data} />
+ </Suspense>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx
index a0e278cb..7a68e3a2 100644
--- a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx
+++ b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx
@@ -135,9 +135,6 @@ export default async function VendorResponsePage({ params }: PageProps) {
)
.orderBy(basicContract.createdAt)
- console.log(basicContracts,"basicContracts")
- console.log(rfqDetail,"rfqDetail")
-
return (
<div className="container mx-auto py-8">
diff --git a/app/[lng]/partners/(partners)/tbe-last/page.tsx b/app/[lng]/partners/(partners)/tbe-last/page.tsx
new file mode 100644
index 00000000..62a982c7
--- /dev/null
+++ b/app/[lng]/partners/(partners)/tbe-last/page.tsx
@@ -0,0 +1,88 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getTBEforVendor } from "@/lib/tbe-last/vendor-tbe-service"
+import { searchParamsTBELastCache } from "@/lib/tbe-last/validations"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { TbeVendorTable } from "@/lib/tbe-last/vendor/tbe-table"
+import * as React from "react"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { InformationButton } from "@/components/information/information-button"
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBELastCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const session = await getServerSession(authOptions)
+ const vendorId = session?.user.companyId
+ // const vendorId = "17"
+
+ const idAsNumber = Number(vendorId)
+
+ const promises = Promise.all([
+ getTBEforVendor({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ TBE 관리
+ </h2>
+ <InformationButton pagePath="partners/tbe" />
+ </div>
+ {/* <p className="text-sm text-muted-foreground">
+ TBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "}
+ </p> */}
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TbeVendorTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/pdftron-viewer/page.tsx b/app/[lng]/pdftron-viewer/page.tsx
new file mode 100644
index 00000000..bde60a41
--- /dev/null
+++ b/app/[lng]/pdftron-viewer/page.tsx
@@ -0,0 +1,507 @@
+// app/pdftron-viewer/page.tsx
+
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft, MessageSquare, Download, Upload } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { useSession } from "next-auth/react"
+import { useToast } from "@/hooks/use-toast"
+import type { WebViewerInstance } from "@pdftron/webviewer"
+
+// PDFTron 코멘트 타입 정의
+interface PDFTronComment {
+ id: number
+ documentReviewId: number
+ pdftronDocumentId: string
+ xfdfString: string
+ annotationData: any
+ commentSummary?: {
+ total: number
+ open: number
+ resolved: number
+ rejected: number
+ deferred: number
+ byCategory: Record<string, number>
+ bySeverity: Record<string, number>
+ byAuthor: Record<string, number>
+ }
+ createdBy: number
+ createdByName?: string
+ createdByType: "buyer" | "vendor"
+ createdAt: Date
+ updatedAt: Date
+}
+
+export default function PDFTronViewerPage() {
+ const { data: session, status } = useSession()
+ const searchParams = useSearchParams()
+ const viewerRef = React.useRef<HTMLDivElement>(null)
+ const [instance, setInstance] = React.useState<WebViewerInstance | null>(null)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [lastSavedTime, setLastSavedTime] = React.useState<Date | null>(null)
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [annotationCount, setAnnotationCount] = React.useState(0)
+ const { toast } = useToast()
+ const initialized = React.useRef(false)
+ const isCancelled = React.useRef(false)
+ const autoSaveTimerRef = React.useRef<NodeJS.Timeout | null>(null)
+ const xfdfLoadedRef = React.useRef(false) // XFDF 로딩 완료 여부 추적
+
+ // URL 파라미터에서 정보 가져오기
+ const filePath = searchParams.get('filePath')
+ const documentId = searchParams.get('documentId')
+ const documentReviewId = searchParams.get('documentReviewId')
+ const sessionId = searchParams.get('sessionId')
+ const documentName = searchParams.get('documentName')
+
+ // PDFTron WebViewer 초기화 - session과 XFDF 모두 준비된 후 실행
+ React.useEffect(() => {
+ if (!initialized.current && viewerRef.current && filePath && session && documentReviewId) {
+ initialized.current = true
+ isCancelled.current = false
+
+ // XFDF 먼저 로드한 후 WebViewer 초기화
+ loadAndInitializeViewer()
+ }
+
+ return () => {
+ if (instance) {
+ try {
+ instance.UI.dispose()
+ } catch (error) {
+ console.warn("Error disposing viewer:", error)
+ }
+ }
+ isCancelled.current = true
+
+ // 타이머 정리
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current)
+ }
+ }
+ }, [filePath, session, documentReviewId, sessionId])
+
+ const loadAndInitializeViewer = async () => {
+ try {
+ // 1. 먼저 기존 XFDF 로드
+ let existingXFDF = ""
+ try {
+ const response = await fetch(`/api/pdftron-comments/xfdf?documentReviewId=${documentReviewId}`)
+ if (response.ok) {
+ const data = await response.json()
+ if (data.xfdfString) {
+ existingXFDF = data.xfdfString
+ console.log("Loaded existing XFDF successfully")
+ }
+ }
+ } catch (error) {
+ console.error("Failed to load XFDF:", error)
+ }
+
+ // 2. WebViewer 초기화
+ await initializeWebViewer(existingXFDF)
+
+ } catch (error) {
+ console.error("Failed to initialize viewer:", error)
+ setIsLoading(false)
+ toast({
+ title: "Error",
+ description: "Failed to initialize document viewer",
+ variant: "destructive"
+ })
+ }
+ }
+
+ const initializeWebViewer = async (existingXFDF: string) => {
+ try {
+ console.log("Starting WebViewer initialization...")
+ console.log("File path:", filePath)
+ console.log("Current session:", session)
+ console.log("Has existing XFDF:", !!existingXFDF)
+
+ // 동적 import 사용
+ const { default: WebViewer } = await import("@pdftron/webviewer")
+
+ if (isCancelled.current || !viewerRef.current) {
+ console.log("WebViewer initialization cancelled")
+ return
+ }
+
+ // WebViewer 인스턴스 생성
+ const webviewerInstance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_LICENSE_KEY || process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ initialDoc: filePath!,
+ },
+ viewerRef.current
+ )
+
+ if (isCancelled.current) {
+ console.log("WebViewer initialization cancelled after creation")
+ return
+ }
+
+ setInstance(webviewerInstance)
+
+ if (!webviewerInstance.Core) {
+ console.error("WebViewer Core is not available")
+ setIsLoading(false)
+ return
+ }
+
+ const { documentViewer, annotationManager, Annotations } = webviewerInstance.Core
+
+ // 현재 사용자 설정
+ const currentUser = session?.user?.email || session?.user?.name || 'Anonymous'
+ console.log("Setting current user:", currentUser)
+ annotationManager.setCurrentUser(currentUser)
+
+ // 권한 설정 - 자기 annotation만 수정/삭제 가능
+ annotationManager.setPermissionCheckCallback((author: string, annotation: any) => {
+ // 자기가 만든 annotation만 수정 가능
+ return author === currentUser
+ })
+
+ // 문서 로드 완료 시
+ documentViewer.addEventListener('documentLoaded', async () => {
+ console.log("Document loaded successfully")
+ setIsLoading(false)
+
+ console.log(existingXFDF)
+
+ // 기존 XFDF 적용
+ if (existingXFDF && !xfdfLoadedRef.current) {
+ console.log(existingXFDF, "existingXFDF")
+
+ try {
+ await annotationManager.importAnnotations(existingXFDF)
+ xfdfLoadedRef.current = true
+ console.log("Imported existing annotations from XFDF")
+
+ // 초기 annotation 수 설정
+ const annotations = annotationManager.getAnnotationsList()
+ setAnnotationCount(annotations.length)
+
+ // 마지막 저장 시간 설정
+ setLastSavedTime(new Date())
+ } catch (error) {
+ console.error("Failed to import XFDF:", error)
+ toast({
+ title: "Warning",
+ description: "Failed to load existing annotations",
+ variant: "destructive"
+ })
+ }
+ }
+
+ // UI 설정 (1초 지연)
+ setTimeout(() => {
+ setupUI()
+ }, 1000)
+ })
+
+ // UI 설정 함수
+ const setupUI = async () => {
+ try {
+ console.log("Setting up UI features...")
+
+ // Review 모드 annotation 도구 활성화
+ try {
+ // 주석 도구 활성화
+ webviewerInstance.UI.enableElements(['highlightToolButton'])
+ webviewerInstance.UI.enableElements(['stickyToolButton'])
+ webviewerInstance.UI.enableElements(['freeTextToolButton'])
+ webviewerInstance.UI.enableElements(['underlineToolButton'])
+ webviewerInstance.UI.enableElements(['strikeoutToolButton'])
+ webviewerInstance.UI.enableElements(['squigglyToolButton'])
+
+ // 노트 패널 열기
+ webviewerInstance.UI.openElements(['notesPanel'])
+ } catch (e) {
+ console.log("Could not enable annotation tools:", e)
+ }
+
+ // 커스텀 이벤트 리스너 설정
+ setupAnnotationListeners()
+ } catch (error) {
+ console.error("Error setting up UI:", error)
+ }
+ }
+
+ // Annotation 이벤트 리스너 설정
+ const setupAnnotationListeners = () => {
+ // 자동 저장 함수
+ const handleAutoSave = async () => {
+ if (!documentReviewId) {
+ console.log("No documentReviewId, skipping auto-save")
+ return
+ }
+
+ // 이미 저장 중이면 스킵
+ if (isSaving) {
+ console.log("Already saving, skipping...")
+ return
+ }
+
+ setIsSaving(true)
+
+ try {
+ const xfdfString = await annotationManager.exportAnnotations()
+
+ // Annotation 요약 정보 생성
+ const annotations = annotationManager.getAnnotationsList()
+ const summary = {
+ total: annotations.length,
+ open: annotations.filter((a: any) => a.getCustomData('status') !== 'resolved').length,
+ resolved: annotations.filter((a: any) => a.getCustomData('status') === 'resolved').length,
+ rejected: annotations.filter((a: any) => a.getCustomData('status') === 'rejected').length,
+ deferred: annotations.filter((a: any) => a.getCustomData('status') === 'deferred').length,
+ byCategory: {} as Record<string, number>,
+ bySeverity: {} as Record<string, number>,
+ byAuthor: {} as Record<string, number>
+ }
+
+ annotations.forEach((annotation: any) => {
+ const category = annotation.getCustomData('category') || 'general'
+ const severity = annotation.getCustomData('severity') || 'minor'
+ const author = annotation.Author || 'Anonymous'
+
+ summary.byCategory[category] = (summary.byCategory[category] || 0) + 1
+ summary.bySeverity[severity] = (summary.bySeverity[severity] || 0) + 1
+ summary.byAuthor[author] = (summary.byAuthor[author] || 0) + 1
+ })
+
+ // 서버에 저장
+ const response = await fetch('/api/pdftron-comments/xfdf', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ documentReviewId: parseInt(documentReviewId),
+ sessionId: sessionId ? parseInt(sessionId) :0,
+ pdftronDocumentId: documentId,
+ xfdfString: xfdfString,
+ commentSummary: summary,
+ createdByType: 'buyer'
+ })
+ })
+
+ if (response.ok) {
+ setLastSavedTime(new Date())
+ setAnnotationCount(annotations.length)
+ console.log("Auto-save successful")
+ } else {
+ console.error("Auto-save failed")
+ toast({
+ title: "Error",
+ description: "Failed to save annotations",
+ variant: "destructive"
+ })
+ }
+ } catch (error) {
+ console.error("Auto-save error:", error)
+ toast({
+ title: "Error",
+ description: "Failed to save annotations",
+ variant: "destructive"
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // Annotation 변경 감지
+ annotationManager.addEventListener('annotationChanged', (annotations: any[], action: string) => {
+ if (action === 'add' || action === 'modify' || action === 'delete') {
+ // 새 annotation에 기본 메타데이터 추가
+ if (action === 'add') {
+ annotations.forEach(annotation => {
+ if (!annotation.getCustomData('category')) {
+ annotation.setCustomData('category', 'general')
+ annotation.setCustomData('severity', 'minor')
+ annotation.setCustomData('status', 'open')
+ annotation.setCustomData('createdBy', session?.user?.id || '')
+ annotation.setCustomData('createdByType', 'buyer')
+ annotation.setCustomData('createdAt', new Date().toISOString())
+
+ // 기본 색상 설정 (minor = yellow)
+ try {
+ if (Annotations) {
+ annotation.Color = new Annotations.Color(250, 204, 21)
+ }
+ } catch (e) {
+ console.log("Could not set annotation color")
+ }
+ }
+ })
+ }
+
+ // 자동 저장 - 2초 디바운싱
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current)
+ }
+
+ autoSaveTimerRef.current = setTimeout(() => {
+ console.log("Auto-saving annotations...")
+ handleAutoSave()
+ }, 2000)
+ }
+ })
+
+ // 코멘트 변경 감지
+ annotationManager.addEventListener('annotationCommentsChanged', () => {
+ // 자동 저장 - 1.5초 디바운싱
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current)
+ }
+
+ autoSaveTimerRef.current = setTimeout(() => {
+ console.log("Auto-saving comments...")
+ handleAutoSave()
+ }, 1500)
+ })
+
+ // Annotation 선택 시 기본값 설정
+ annotationManager.addEventListener('annotationSelected', (annotations: any, action: string) => {
+ if (annotations && annotations.length > 0) {
+ const annotation = annotations[0]
+
+ // 기본 커스텀 데이터 설정
+ if (!annotation.getCustomData('category')) {
+ annotation.setCustomData('category', 'general')
+ annotation.setCustomData('severity', 'minor')
+ annotation.setCustomData('status', 'open')
+ annotation.setCustomData('createdBy', session?.user?.id || '')
+ annotation.setCustomData('createdByType', 'buyer')
+ annotation.setCustomData('createdAt', new Date().toISOString())
+ }
+ }
+ })
+ }
+
+ } catch (error) {
+ console.error("WebViewer initialization failed:", error)
+ setIsLoading(false)
+ toast({
+ title: "Error",
+ description: "Failed to initialize document viewer",
+ variant: "destructive"
+ })
+ }
+ }
+
+
+
+ // 통계 정보 가져오기
+ const getAnnotationStats = () => {
+ if (!instance) return null
+
+ const { annotationManager } = instance.Core
+ const annotations = annotationManager.getAnnotationsList()
+
+ return {
+ total: annotations.length,
+ open: annotations.filter((a: any) => a.getCustomData('status') !== 'resolved').length,
+ resolved: annotations.filter((a: any) => a.getCustomData('status') === 'resolved').length
+ }
+ }
+
+ // 시간 포맷팅
+ const formatLastSaved = () => {
+ if (!lastSavedTime) return null
+
+ const now = new Date()
+ const diff = Math.floor((now.getTime() - lastSavedTime.getTime()) / 1000)
+
+ if (diff < 60) return "Just saved"
+ if (diff < 3600) return `Saved ${Math.floor(diff / 60)} min ago`
+ if (diff < 86400) return `Saved ${Math.floor(diff / 3600)} hours ago`
+ return `Saved ${Math.floor(diff / 86400)} days ago`
+ }
+
+ const stats = getAnnotationStats()
+ const lastSavedText = formatLastSaved()
+
+ return (
+ <div className="flex flex-col h-screen overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b bg-background flex-shrink-0">
+ <div className="flex items-center gap-4">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => window.close()}
+ >
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ Back
+ </Button>
+ <div>
+ <h1 className="text-lg font-semibold">{documentName || 'Document Viewer'}</h1>
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <span>Review Mode</span>
+ <span>•</span>
+ <span>User: {session?.user?.email || session?.user?.name || 'Loading...'}</span>
+ {stats && stats.total > 0 && (
+ <>
+ <span>•</span>
+ <Badge variant="outline">
+ <MessageSquare className="h-3 w-3 mr-1" />
+ {stats.open} open / {stats.total} total
+ </Badge>
+ </>
+ )}
+ {isSaving && (
+ <>
+ <span>•</span>
+ <Badge variant="outline" className="text-blue-600 border-blue-600">
+ <div className="animate-pulse">Auto-saving...</div>
+ </Badge>
+ </>
+ )}
+ {!isSaving && lastSavedText && (
+ <>
+ <span>•</span>
+ <Badge variant="outline" className="text-green-600 border-green-600">
+ ✓ {lastSavedText}
+ </Badge>
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+
+ </div>
+
+ {/* PDFTron Viewer */}
+ <div className="flex-1 relative overflow-hidden">
+ {(isLoading || status === "loading") && (
+ <div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
+ <p className="text-sm text-muted-foreground">
+ {status === "loading" ? "Loading session..." : "Loading document..."}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ Initializing PDFTron viewer...
+ </p>
+ </div>
+ </div>
+ )}
+ <div
+ ref={viewerRef}
+ className="h-full w-full"
+ style={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ }}
+ />
+ </div>
+ </div>
+ )
+} \ No newline at end of file